#!/bin/bash
# Copyright (C) 2010 Timothy McGrath <tmhikaru@gmail.com>
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# But WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA

# defrag v0.14 by Timothy McGrath <tmhikaru@gmail.com>

# defrag v0.08 by Con Kolivas <kernel@kolivas.org>
# defrag v0.08 and all earlier versions were created by Con Kolivas. I could
# not have invented such a useful script myself, I have merely improved upon
# the work of another.
# Please do not contact Con Kolivas for support with this program.

if [ "$1" ]; then
	for arg
	do
    		delim=""
        	case "$arg" in
        		#translate --gnu-long-options to -g (short options)
        		--)args="${args}--help ";; #Hack to workaround -- driving getopts insane
        		--safe) args="${args}-s ";;
        		--reverse-sort) args="${args}-r ";;
        		--no-filefrag) args="${args}-N ";;
        		--fragsort) args="${args}-f ";;
        		--defrag) args="${args}-d ";;
        		--no-e4defrag) args="${args}-4 ";;
        		--file)args="${args}-L ";;
        		#pass through anything else
        		*) [[ "${arg:0:1}" == "-" ]] || delim="\""
        		args="${args}${delim}${arg}${delim} ";;
        	esac
        done

	#Reset the positional parameters to the short options
	eval set -- $args
	while getopts "srNfdDL:" arg;
		do
		case $arg in
			s) safe=TRUE;;
			r) reverse=TRUE;;
			N) nofilefrag=TRUE;;
			f) fragsort=TRUE;;
			d) defrag=TRUE;;
			4) noe4defrag=TRUE;;
			L) filelist="$OPTARG";;
			*)
			# Default, so people doing --help/-h get some output
			echo "defrag v0.14 by Timothy McGrath <tmhikaru@gmail.com>"
			echo "defrag v0.08 and earlier by Con Kolivas <kernel@kolivas.org>"
			echo "Please do not contact Con Kolivas for support with this program."
			echo
			echo "Braindead fs-agnostic defrag to rewrite files. Run this in the directory you"
			echo "want all the files and subdirectories to be reordered. It will only affect one"
			echo "partition. It works best when run twice."
			echo "Are you really crazy enough to be using this? It might blow your data into tiny"
			echo "little useless chunks."
			echo
			echo "Options:"
			echo "-s, --safe          Check for open files. Much slower, but safer"
			echo "-r, --reverse-sort  Invert sort, largest directories and files are processed"
			echo "                    first."
			echo "-f, --fragsort      Sort by most fragmented files first. This tends to unwedge"
			echo "                    defragmenting when larger files are very badly fragmented."
			echo "-N, --no-filefrag   Use this option if you know your filesystem is not supported"
			echo "                    by the filefrag program and defrag is guessing this wrong"
			echo "-4, --no-e4defrag   Use this option if you know you are not using ext4 as your"
			echo "                    filesystem and defrag is trying to use e4defrag anyway."
			echo "-d, --defrag        Actually try to defragment the filesystem rather than just"
			echo "                    report how fragmented things are."
			echo "-L, --file	  A single file to be defragmented."
			exit 0;;
		esac
	done
fi

echo arg $arg
echo safe $safe
echo reverse $reverse
echo nofilefrag $nofilefrag
echo fragsort $fragsort
echo defrag $defrag
echo noe4defrag $noe4defrag
echo filelist $filelist

trap 'abort' 1 2 15

renice 19 $$ > /dev/null

abort()
{
	echo -e "\nAborting"
	rm -f tmpfile dirlist totalfrags
	exit 1
}

fail()
{
	echo -e "\nFailed"
	abort
}

declare -i filesize=0
declare -i numfiles=0
declare -i oldfrags=0
declare -i newfrags=0
declare -i oldfragstotal=0
declare -i newfragstotal=0
declare -i fragmentedfilestotal=0

#The maximum size of a file we can easily cache in ram
declare -i maxsize=$((`awk '/MemTotal/ {print $2}' /proc/meminfo`*1024))
(( maxsize-= `awk '/Mapped/ {print $2}' /proc/meminfo` ))
(( maxsize/= 2))

if [[ -a fragstats ]] ; then
	echo "fragstats exists

If you ran defrag previously, fragstats is created to give statistics and
other information on what it has done.  Note that this will still be created
if defrag was run with defragmentation turned off, in that case it reports
the fragmentation of files it tested.  If you are sure that defrag created
this file, feel free to delete it, or move it out of the way if you want to
look at it later."
	exit 1
fi

if [[ -a tmpfile || -a dirlist || -a totalfrags ]] ; then
	echo "dirlist, tmpfile or totalfrags exists

These are either temporary files created by defrag when it was interrupted
or created by another program.  If these files were created by defrag feel
free to delete them and run defrag again."

	exit 1
fi


# Find filefrag, then test if it is functional (functional period, or this
# filesystem, doesn't matter. If it doesn't work it doesn't work)
if [ "$nofilefrag" != "TRUE" ]; then
	#Lets try to find filefrag in some likely places
	#Was it set by the user on the command line?
	if [ "$filefrag" == "" ]; then
		#Nope. Is it on the path?
		which filefrag &> /dev/null
		if [ $? -eq 0 ]; then
       			filefrag="filefrag"
		fi
	fi	
	if [ "$filefrag" == "" ]; then
		#Not on the path. Is it in /sbin?
		which /sbin/filefrag &> /dev/null
		if [ $? -eq 0 ]; then
       			filefrag="/sbin/filefrag"
		else
			#Stumped!
			echo "Can't find filefrag, do you have e2fsprogs installed?"
			echo "Run with filefrag=/path/to/filefrag $0"
			echo "if it is installed but I can't find it."
			echo
			echo "If it is not installed you can use the --no-filefrag"
			echo "switch, but this will make defragmentation much less"
			echo "efficient and is not recommended."
			abort
		fi
	fi
	#Found an executable, but does it work?
	echo "1" > tmpfile
	fragtest=`"$filefrag" -s "tmpfile" | grep " found" | sed -e 's/.*: //' | sed -e 's/ extent.*//'`
	if [ "$fragtest" == "" ]; then
		echo "$filefrag is broken or otherwise giving us strange output. DISABLED."
		nofilefrag=TRUE
	fi
	rm -f tmpfile
fi


# Find e4defrag, then test if it is functional.
if [ "$noe4defrag" != "TRUE" ] && [ "$defrag" == "TRUE" ]; then
	#Lets try to find e4defrag in some likely places
	#Was it set by the user on the command line?
	if [ "$e4defrag" == "" ]; then
		#Nope. Is it on the path?
		which e4defrag &> /dev/null
		if [ $? -eq 0 ]; then
       			e4defrag="e4defrag"
		fi
	fi	
	if [ "$e4defrag" == "" ]; then
		#Not on the path. Is it in /sbin?
		which /sbin/e4defrag &> /dev/null
		if [ $? -eq 0 ]; then
       			e4defrag="/sbin/e4defrag"
		else
			#Stumped!
			echo "Can't find e4defrag, do you have e2fsprogs installed?"
			echo "Run with e4defrag=/path/to/e4defrag $0"
			echo "if it is installed but I can't find it."
			echo
			echo "If it is not installed you can use the --no-e4defrag"
			echo "switch, but this will make defragmentation much less"
			echo "efficient on ext4 filesystems and is not recommended"
			echo "unless you are using something other than ext4."
			abort
		fi
	fi
	#Found an executable, but does it work?
	echo "1" > tmpfile
	progtest=`"$e4defrag" "tmpfile" |& grep " Success:"`
	if [ "$progtest" == "" ]; then
		echo "$e4defrag is broken or otherwise not working on this filesystem. DISABLED."
		noe4defrag=TRUE
	fi
	rm -f tmpfile
fi

if [ "$defrag" != "TRUE" ]; then
	if [ "$nofilefrag" == "TRUE" ]; then
		echo "Not able to report fragmentation without filefrag available. Aborting!"
		abort
	fi
	echo "Defragmentation is disabled. Will only report fragmentation of files."
else
	echo "Defragmentation is enabled"
fi

if [ "$fragsort" == "TRUE" ]; then
	if [ "$nofilefrag" == "TRUE" ]; then
		echo "Cannot sort by frags, filefrag is unusable on this filesystem or otherwise disabled!"
		abort
	fi
	echo -e "Checking ten most fragmented files"
else
	if [ "$reverse" != "TRUE" ]; then
		echo -e "Files sorted by size ascending"
	else
		echo -e "Files sorted by size descending"
	fi
fi

if [ "$safe" != "TRUE" ]; then
	echo -e "Won't skip open files (Faster, but less safe)"
else
	echo -e "Will skip open files (Slower, but safer)"
fi

if [ "$nofilefrag" == "TRUE" ]; then
	echo "Fragmentation checking via filefrag disabled by user or is otherwise nonfunctional on"
	echo "this filesystem. Defrag will still attempt to perform work, but will be less efficient"
fi

if [ "$noe4defrag" != "TRUE" ] && [ "$defrag" == "TRUE" ]; then
	echo -e "Woohoo! e4defrag available on ext4, will use it to defragment files"
fi

if [ "$filelist" == "" ]; then

	echo "Creating list of files..."
else
	echo "Explicitly only checking $filelist"
fi


if [ "$fragsort" != "TRUE" ] && [ "$filelist" == "" ]; then
	#sort files in descending/ascending size order only

	if [ "$reverse" != "TRUE" ]; then
		find -xdev \! -name totalfrags \! -name fragstats \! -name dirlist \! -links +1 -type f -printf "%s\t%p\n" | \
		sort -k 1,1n | \
		cut -f2 >> dirlist
	else
		find -xdev \! -name totalfrags \! -name fragstats \! -name dirlist \! -links +1 -type f -printf "%s\t%p\n" | \
		sort -k 1,1nr | \
		cut -f2 >> dirlist
	fi
	if (( $? )) ; then
		fail
	fi
                                                                                                                                                                
elif [ "$fragsort" == "TRUE" ] && [ "$filelist" == "" ]; then
	#sort files in descending by frags only, limit the list to the first ten files
	find -xdev \! -name totalfrags \! -name fragstats \! -name dirlist \! -links +1 -type f -exec "$filefrag" -s "{}" \; | \
	sed -n '/found/s/\(^.*\): \([0-9]*\)[^:]*$/\2 \1/p' | \
	sort -nr | \
	sed -e 's/^[0-9]* //' | \
	head >> dirlist

	if (( $? )) ; then
		fail
	fi
else
	#Manual invocation to check one file only
	echo "$filelist" > dirlist
fi

numfiles=`wc -l dirlist | awk '{print $1}'`
echo -e "$numfiles files will be checked\n"

#copy to temp file, check the file hasn't changed and then overwrite original
#unless on ext4; on ext4 use e4defrag when available to defragment the file instead
cat dirlist | while read i;
do
	(( --numfiles ))

	if [[ ! -f "$i" ]]; then
		echo -e "\r$numfiles files left. Current MISSING, -- SKIPPED                               \c"
		continue
	fi

	# This is safer, but much slower.
	if [ "$safe" == "TRUE" ] && [ "$defrag" == "TRUE" ]; then
		if [[ `lsof -f -- "$i"` ]]; then
			echo -e "\r$numfiles files left. Current OPEN, -- SKIPPED                                  \c"
			continue
		fi
	fi

	filesize=`find "$i" -printf "%s"`
	if [ "$nofilefrag" != TRUE ]; then
		oldfrags=`"$filefrag" -s "$i" | grep " found" | sed -e 's/.*: //' | sed -e 's/ extent.*//'`

		if [ "$oldfrags" -le 1 ]; then #A file with no contents can have 0 extents!
			#What are we bothering with a perfect file for?
			echo "$oldfragstotal $newfragstotal $fragmentedfilestotal" > totalfrags
			echo -e "\r$numfiles files left. Current NOT FRAGMENTED, -- SKIPPED                        \c"
			continue
		elif [ "$defrag" != "TRUE" ]; then
			#We're not defragmenting
			oldfragstotal+="$oldfrags"
			newfragstotal+="$oldfrags"
			fragmentedfilestotal+="1"
			echo "$oldfragstotal $newfragstotal $fragmentedfilestotal" > totalfrags
			echo -e "\r$numfiles files left. Defragmentation disabled.                                 \c"
			echo "$i has $oldfrags fragments." >> fragstats
			continue
		fi

		oldfragstotal+="$oldfrags"
		fragmentedfilestotal+="1"
		echo "$oldfragstotal $newfragstotal $fragmentedfilestotal" > totalfrags
	fi

	# read the file first to cache it in ram when possible if we're not defragmenting an ext4 filesystem
	if (( filesize < maxsize )) && [ "$noe4defrag" == "TRUE" ]; then
		# Cut down the filename&path to fit on an 80 column screen
		pathlen=80 #80 columns
		firstpart="$numfiles files left. Current "
		len="${#firstpart}"
		pathlen=$((pathlen - len))
		if [ "${#i}" -gt "${pathlen}" ] ; then
			offset=$[${#i} - ${pathlen}]
		else
			offset=0
		fi
		secondpart=${i:${offset}:${#i}}
		echo -e "\r$firstpart$secondpart\c"
		cat "$i" > /dev/null
	else
		# Cut down the filename&path to fit on an 80 column screen
		pathlen=80 #80 columns
		firstpart="$numfiles files left. Current sized $filesize is "
		len="${#firstpart}"
		pathlen=$((pathlen - len))
		if [ "${#i}" -gt "${pathlen}" ] ; then
			offset=$[${#i} - ${pathlen}]
		else
			offset=0
		fi
		secondpart=${i:${offset}:${#i}}
		echo -e "\r$firstpart$secondpart\c"
	fi

	datestamp=`find "$i" -printf "%s"`

	if [ "$noe4defrag" == "TRUE" ]; then
		cp -a -f "$i" tmpfile
	else
		$e4defrag "$i" &>/dev/null
	fi

	if (( $? )) ; then
		fail
	fi

	# Clear the previous line's data
	echo -e "\r                                                                                \c"

	if [ "$nofilefrag" != "TRUE" ]; then
		if [ "$noe4defrag" == "TRUE" ]; then
			newfrags=`"$filefrag" -s "tmpfile" | grep " found" | sed -e 's/.*: //' | sed -e 's/ extent.*//'`
		else
			newfrags=`"$filefrag" -s "$i" | grep " found" | sed -e 's/.*: //' | sed -e 's/ extent.*//'`
		fi
		if [ "$oldfrags" -lt "$newfrags" ]; then
			echo "$i got WORSE! old $oldfrags new $newfrags" >> fragstats
			newfragstotal+="$oldfrags"
			echo "$oldfragstotal $newfragstotal $fragmentedfilestotal" > totalfrags
			if [ "$noe4defrag" == "TRUE" ]; then
				rm -f "tmpfile"
			fi
			continue
		fi
		newfragstotal+="$newfrags"
		echo "$oldfragstotal $newfragstotal $fragmentedfilestotal" > totalfrags
	fi

	# check the file hasn't been altered since we copied it and we're not using ext4
	if [[ `find "$i" -printf "%s"` != "$datestamp" ]] && [ "$noe4defrag" == "TRUE" ]; then
		rm -f "tmpfile"
		continue
	fi

	if [ "$noe4defrag" == "TRUE" ]; then
		mv -f tmpfile "$i"
		if (( $? )) ; then
			fail
		fi
	fi

	if [ "$nofilefrag" != "TRUE" ]; then
		if [ "$oldfrags" -gt "$newfrags" ]; then
			echo "$i improved old $oldfrags new $newfrags" >> fragstats
		elif [ "$oldfrags" -eq "$newfrags" ]; then
			echo "$i is the same" >> fragstats
		fi
	fi
done

touch totalfrags
if [ "$nofilefrag" != "TRUE" ] && [ "$defrag" == "TRUE" ]; then
	oldfragstotal=`cat totalfrags | cut -d" " -f1`
	newfragstotal=`cat totalfrags | cut -d" " -f2`
	fragmentedfilestotal=`cat totalfrags | cut -d" " -f3`
	rm -f totalfrags

	#prevent divide by zero
	if [ "$fragmentedfilestotal" == "0" ]; then
		divide="1"
	else
		divide=$fragmentedfilestotal
	fi
	if [ "$oldfragstotal" == "0" ]; then
		dividefrags=1
	else
		dividefrags=$oldfragstotal
	fi

	totalpercentage=`echo "scale=2; $newfragstotal / $dividefrags * 100" | bc`
	avgbeforefile=`echo "scale=2; $oldfragstotal / $divide" | bc`
	avgafterfile=`echo "scale=2; $newfragstotal / $divide" | bc`

	echo -e "\nSucceeded, files now $totalpercentage% as fragmented as before"
	echo 	"We checked $numfiles files for fragmentation. Of them, $fragmentedfilestotal were."
	echo    "Before defragmentation they had $oldfragstotal extents"
	echo    "After they have $newfragstotal extents"
	echo	"For an average before of $avgbeforefile per fragmented file"
	echo    "And now an average of $avgafterfile per fragmented file"
	echo	"Read the contents of fragstats to see what work was done."
	echo	"Note that if a file is not fragmented it will not be listed."
	echo	"Also note if there were no fragmented files at all, the file will not exist!"

elif [ "$nofilefrag" != "TRUE" ] && [ "$defrag" != "TRUE" ]; then
	oldfragstotal=`cat totalfrags | cut -d" " -f1`
	fragmentedfilestotal=`cat totalfrags | cut -d" " -f3`
	rm -f totalfrags

	#prevent divide by zero
	if [ "$fragmentedfilestotal" == "0" ]; then
		divide="1"
	else
		divide=$fragmentedfilestotal
	fi
	avgbeforefile=`echo "scale=2; $oldfragstotal / $divide" | bc`


	echo -e "\nFinished checking fragmentation of files."
	echo    "We checked $numfiles files for fragmentation. Of them, $fragmentedfilestotal were."
	echo    "Found that the fragmented files have a total of $oldfragstotal extents"
	echo    "for an average of $avgbeforefile per fragmented file"
	echo 	"Read the contents of fragstats to see which files have fragmentation."
	echo	"Note that if a file is not fragmented it will not be listed."
	echo	"Also note if there were no fragmented files at all, the file will not exist!"
else
	echo -e "\nSucceeded, but could not generate statistics because filefrag is"
	echo    "nonfunctional or you used --no-filefrag"
fi

rm -f dirlist
